import io
import json
import shutil
import tempfile
from unittest import mock

from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone

from accounts.models import UserNotification
from learning import services
from learning.models import Mistake, MistakeLLMMessage, MistakeLLMConversation
from PIL import Image


class MistakeModelTests(TestCase):
    def setUp(self):
        self.User = get_user_model()
        self.teacher = self.User.objects.create_user(
            username="mentor",
            password="temp-pass",
            role=self.User.Role.TEACHER,
        )
        self.student = self.User.objects.create_user(
            username="learner",
            password="temp-pass",
            role=self.User.Role.STUDENT,
            assigned_teacher=self.teacher,
        )

    def _make_mistake(self, **kwargs):
        defaults = {
            "student": self.student,
            "title": "Quadratic equation mistake",
            "question": "Solve x^2 - 5x + 6 = 0",
            "solution_attempt": "I factored it as (x-1)(x-6).",
            "correct_solution": "Factors are (x-2)(x-3).",
            "reflection": "Forgot to check the multiplication result.",
        }
        defaults.update(kwargs)
        return Mistake.objects.create(**defaults)

    def test_student_must_have_student_role(self):
        teacher_mistake = Mistake(
            student=self.teacher,
            title="Teacher entry",
            question="N/A",
        )

        with self.assertRaises(ValidationError):
            teacher_mistake.full_clean()

    def test_submit_for_review_sets_status_and_timestamp(self):
        mistake = self._make_mistake()

        mistake.submit_for_review()

        self.assertEqual(mistake.status, Mistake.Status.SUBMITTED)
        self.assertIsNotNone(mistake.submitted_at)
        self.assertLessEqual(mistake.submitted_at, timezone.now())

    def test_resolve_requires_assigned_teacher(self):
        other_teacher = self.User.objects.create_user(
            username="other-mentor",
            password="temp-pass",
            role=self.User.Role.TEACHER,
        )
        mistake = self._make_mistake(status=Mistake.Status.SUBMITTED)

        with self.assertRaises(PermissionError):
            mistake.resolve(reviewer=other_teacher, feedback="Looks good now.")

        mistake.resolve(reviewer=self.teacher, feedback="Remember to FOIL.")

        self.assertEqual(mistake.status, Mistake.Status.RESOLVED)
        self.assertIsNotNone(mistake.resolved_at)
        self.assertEqual(mistake.teacher_feedback, "Remember to FOIL.")

    def test_record_llm_message_appends_history(self):
        mistake = self._make_mistake()

        mistake.record_llm_message(
            sender=MistakeLLMMessage.Sender.STUDENT,
            content="Why is my factorization wrong?",
        )
        mistake.record_llm_message(
            sender=MistakeLLMMessage.Sender.LLM,
            content="Because (x-1)(x-6) expands to x^2 - 7x + 6.",
        )

        messages = list(mistake.llm_messages.order_by("created_at"))
        self.assertEqual(len(messages), 2)
        self.assertEqual(messages[0].sender, MistakeLLMMessage.Sender.STUDENT)
        self.assertIn("factorization wrong", messages[0].content)
        self.assertEqual(messages[1].sender, MistakeLLMMessage.Sender.LLM)


class DifyClientTests(TestCase):
    def setUp(self):
        self.client = services.DifyClient(api_key="test-key")

    @mock.patch("learning.services.urllib_request.urlopen")
    def test_send_question_to_dify(self, mock_urlopen):
        mock_response = mock.MagicMock()
        mock_response.__enter__.return_value = mock_response
        mock_response.read.return_value = json.dumps({"answer": "Use quadratic formula"}).encode()
        mock_response.status = 200
        mock_urlopen.return_value = mock_response

        result = self.client.ask(
            query="Explain quadratic formula",
            user_id="student-123",
            conversation_id="conv-1",
            files=[{"type": "image", "transfer_method": "remote_url", "url": "https://example.com/diagram.png"}],
        )

        self.assertEqual(result["answer"], "Use quadratic formula")
        request_arg = mock_urlopen.call_args.args[0]
        self.assertEqual(request_arg.get_full_url(), services.DifyClient.API_URL)
        self.assertEqual(request_arg.headers["Authorization"], "Bearer test-key")
        payload = json.loads(request_arg.data.decode())
        self.assertEqual(payload["query"], "Explain quadratic formula")
        self.assertEqual(payload["user"], "student-123")
        self.assertEqual(payload["conversation_id"], "conv-1")
        self.assertEqual(payload["response_mode"], "blocking")

    @mock.patch("learning.services.urllib_request.urlopen")
    def test_dify_error_status_raises(self, mock_urlopen):
        mock_response = mock.MagicMock()
        mock_response.__enter__.return_value = mock_response
        mock_response.read.return_value = b'{"error":"bad request"}'
        mock_response.status = 500
        mock_urlopen.return_value = mock_response

        with self.assertRaises(services.DifyClientError):
            self.client.ask(query="Explain quadratic formula", user_id="student-123")

    @mock.patch("learning.services.urllib_request.urlopen")
    def test_get_messages_fetches_history(self, mock_urlopen):
        mock_response = mock.MagicMock()
        mock_response.__enter__.return_value = mock_response
        mock_response.read.return_value = json.dumps({"data": [], "limit": 20, "has_more": False}).encode()
        mock_response.status = 200
        mock_urlopen.return_value = mock_response

        result = self.client.get_messages(user_id="student-123", conversation_id="conv-1")

        self.assertIn("data", result)
        request_arg = mock_urlopen.call_args.args[0]
        self.assertIn("conversation_id=conv-1", request_arg.full_url)
        self.assertIn("user=student-123", request_arg.full_url)
        self.assertEqual(request_arg.get_method(), "GET")

    @mock.patch("learning.services.urllib_request.urlopen")
    def test_delete_conversation_issues_delete_request(self, mock_urlopen):
        mock_response = mock.MagicMock()
        mock_response.__enter__.return_value = mock_response
        mock_response.read.return_value = b""
        mock_response.status = 204
        mock_urlopen.return_value = mock_response

        self.client.delete_conversation(user_id="student-123", conversation_id="conv-1")

        request_arg = mock_urlopen.call_args.args[0]
        self.assertEqual(request_arg.get_method(), "DELETE")
        self.assertIn("/conversations/conv-1", request_arg.full_url)
        self.assertEqual(json.loads(request_arg.data.decode())["user"], "student-123")


@override_settings(DIFY_API_KEY="fake-key")
class MistakeViewTests(TestCase):
    def setUp(self):
        self.temp_media_dir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.temp_media_dir, ignore_errors=True)
        self.override_media = override_settings(MEDIA_ROOT=self.temp_media_dir)
        self.override_media.enable()
        self.addCleanup(self.override_media.disable)

        User = get_user_model()
        self.teacher = User.objects.create_user(
            username="teacher",
            password="pass1234",
            role=User.Role.TEACHER,
        )
        self.student = User.objects.create_user(
            username="student",
            password="pass1234",
            role=User.Role.STUDENT,
            assigned_teacher=self.teacher,
        )
        self.other_teacher = User.objects.create_user(
            username="intruder",
            password="pass1234",
            role=User.Role.TEACHER,
        )

    def _login(self, user):
        logged_in = self.client.login(username=user.username, password="pass1234")
        self.assertTrue(logged_in)

    def _make_test_image(self, name: str = "test.png") -> SimpleUploadedFile:
        buffer = io.BytesIO()
        image = Image.new("RGB", (2, 2), color=(255, 255, 255))
        image.save(buffer, format="PNG")
        return SimpleUploadedFile(name, buffer.getvalue(), content_type="image/png")

    def test_student_can_create_mistake(self):
        self._login(self.student)

        response = self.client.post(
            reverse("learning:mistake_create"),
            data={
                "title": "New mistake",
                "question": "What is 2 + 2?",
                "solution_attempt": "I wrote 5.",
                "correct_solution": "",
                "reflection": "Careless error",
            },
        )

        self.assertEqual(response.status_code, 302)
        mistake = Mistake.objects.get()
        self.assertEqual(mistake.student, self.student)
        self.assertEqual(mistake.status, Mistake.Status.DRAFT)

    def test_teacher_cannot_create_student_mistake(self):
        self._login(self.teacher)

        response = self.client.post(
            reverse("learning:mistake_create"),
            data={"title": "Invalid", "question": "Why?"},
        )

        self.assertEqual(response.status_code, 403)
        self.assertFalse(Mistake.objects.exists())

    def test_assigned_teacher_can_resolve_mistake(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="Quadratic slip",
            question="x^2 + x + 1 = 0",
            status=Mistake.Status.SUBMITTED,
        )
        self._login(self.teacher)

        response = self.client.post(
            reverse("learning:mistake_resolve", args=[mistake.pk]),
            data={"feedback": "Discuss in class."},
        )

        self.assertEqual(response.status_code, 302)
        mistake.refresh_from_db()
        self.assertEqual(mistake.status, Mistake.Status.RESOLVED)
        self.assertEqual(mistake.teacher_feedback, "Discuss in class.")

    @mock.patch("learning.views.services.DifyClient.ask")
    def test_student_can_request_llm_explanation(self, mock_ask):
        mock_ask.return_value = {
            "answer": "Check your factoring.",
            "conversation_id": "conv-xyz",
        }
        mistake = Mistake.objects.create(
            student=self.student,
            title="Factor error",
            question="x^2 - 7x + 10",
            solution_attempt="I thought it was (x-5)(x-2)",
            correct_solution="(x-5)(x-2)",
            reflection="Need to check multiplication.",
            question_image=self._make_test_image("question.png"),
            solution_attempt_image=self._make_test_image("solution.png"),
        )
        self._login(self.student)

        response = self.client.post(
            reverse("learning:mistake_ask_llm", args=[mistake.pk]),
            data={"question": "Why is my solution wrong?"},
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        payload = response.json()
        self.assertEqual(payload["conversation_id"], "conv-xyz")
        self.assertEqual(payload["answer"], "Check your factoring.")
        mock_ask.assert_called_once()
        kwargs = mock_ask.call_args.kwargs
        self.assertEqual(kwargs["user_id"], f"user-{mistake.student_id}")
        self.assertIn("inputs", kwargs)
        self.assertEqual(kwargs["inputs"]["mistake_question"], mistake.question)
        self.assertEqual(kwargs["inputs"]["mistake_status"], mistake.get_status_display())
        files_payload = kwargs.get("files")
        self.assertIsInstance(files_payload, list)
        self.assertEqual(len(files_payload), 2)
        expected_question_url = f"http://testserver{mistake.question_image.url}"
        expected_solution_url = f"http://testserver{mistake.solution_attempt_image.url}"
        self.assertEqual(
            files_payload[0],
            {
                "type": "image",
                "transfer_method": "remote_url",
                "url": expected_question_url,
            },
        )
        self.assertEqual(
            files_payload[1],
            {
                "type": "image",
                "transfer_method": "remote_url",
                "url": expected_solution_url,
            },
        )
        mistake.refresh_from_db()
        self.assertEqual(mistake.llm_conversation_id, "conv-xyz")
        self.assertEqual(mistake.llm_messages.count(), 0)
        conv_record = MistakeLLMConversation.objects.get(conversation_id="conv-xyz")
        self.assertTrue(conv_record.is_active)
        self.assertEqual(conv_record.mistake_id, mistake.id)
        self.assertIsNone(conv_record.archived_at)

    @mock.patch("learning.views.services.DifyClient.ask")
    def test_llm_request_returns_error_when_dify_unreachable(self, mock_ask):
        mock_ask.side_effect = services.DifyClientError("gateway timeout")
        mistake = Mistake.objects.create(
            student=self.student,
            title="Network issue",
            question="Explain cos rule",
        )
        self._login(self.student)

        response = self.client.post(
            reverse("learning:mistake_ask_llm", args=[mistake.pk]),
            data={"question": "What happens?"},
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 502)
        payload = response.json()
        self.assertIn("gateway timeout", payload["errors"]["__all__"][0])

    @override_settings(DIFY_FILES_UPLOAD_ENABLED=True)
    @mock.patch("learning.views.services.DifyClient.upload_file")
    @mock.patch("learning.views.services.DifyClient.ask")
    def test_llm_request_uses_uploaded_file_ids(self, mock_ask, mock_upload):
        mock_ask.return_value = {
            "answer": "Uploaded file reply",
            "conversation_id": "conv-upload",
        }
        mock_upload.side_effect = [
            {"id": "file-question"},
            {"id": "file-solution"},
        ]
        mistake = Mistake.objects.create(
            student=self.student,
            title="Upload path",
            question="Why upload?",
            question_image=self._make_test_image("question.png"),
            solution_attempt="Answer",
            solution_attempt_image=self._make_test_image("solution.png"),
        )
        self._login(self.student)

        response = self.client.post(
            reverse("learning:mistake_ask_llm", args=[mistake.pk]),
            data={"question": "Explain?"},
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        files_payload = mock_ask.call_args.kwargs.get("files") or []
        self.assertEqual(len(files_payload), 2)
        self.assertTrue(all(f["transfer_method"] == "local_file" for f in files_payload))
        self.assertEqual(files_payload[0]["upload_file_id"], "file-question")
        self.assertEqual(files_payload[1]["upload_file_id"], "file-solution")
        self.assertEqual(mock_upload.call_count, 2)

    @override_settings(DIFY_FILES_BASE_URL="https://cdn.example.com/media")
    @mock.patch("learning.views.services.DifyClient.ask")
    def test_llm_request_uses_custom_file_base(self, mock_ask):
        mock_ask.return_value = {
            "answer": "Custom host reply",
            "conversation_id": "conv-custom",
        }
        mistake = Mistake.objects.create(
            student=self.student,
            title="Custom base",
            question="Use CDN",
            question_image=self._make_test_image("question.png"),
        )
        self._login(self.student)

        response = self.client.post(
            reverse("learning:mistake_ask_llm", args=[mistake.pk]),
            data={"question": "Check file host?"},
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        files_payload = mock_ask.call_args.kwargs.get("files") or []
        self.assertEqual(len(files_payload), 1)
        self.assertTrue(
            files_payload[0]["url"].startswith("https://cdn.example.com/media")
        )

    @override_settings(DIFY_FORCE_HTTPS_FILES=True)
    @mock.patch("learning.views.services.DifyClient.ask")
    def test_llm_request_forces_https_urls(self, mock_ask):
        mock_ask.return_value = {
            "answer": "Force HTTPS",
            "conversation_id": "conv-secure",
        }
        mistake = Mistake.objects.create(
            student=self.student,
            title="HTTPS force",
            question="Ensure secure",
            question_image=self._make_test_image("secure.png"),
        )
        self._login(self.student)

        response = self.client.post(
            reverse("learning:mistake_ask_llm", args=[mistake.pk]),
            data={"question": "Why https?"},
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        files_payload = mock_ask.call_args.kwargs.get("files") or []
        self.assertEqual(len(files_payload), 1)
        self.assertTrue(files_payload[0]["url"].startswith("https://"))

    def test_student_can_prepare_new_llm_conversation(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="Fresh conversation",
            question="Why new?",
            llm_conversation_id="conv-old",
        )
        mistake.record_llm_message(
            sender=MistakeLLMMessage.Sender.STUDENT,
            content="旧消息",
        )
        MistakeLLMConversation.objects.create(
            mistake=mistake,
            conversation_id="conv-old",
            is_active=True,
        )

        self._login(self.student)

        response = self.client.post(
            reverse("learning:mistake_new_llm_conversation", args=[mistake.pk]),
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        payload = response.json()
        self.assertEqual(payload["conversation_id"], "")
        self.assertEqual(payload["previous_id"], "conv-old")
        self.assertIn("message", payload)

        mistake.refresh_from_db()
        self.assertEqual(mistake.llm_conversation_id, "")
        self.assertEqual(mistake.llm_messages.count(), 0)
        conv_record = MistakeLLMConversation.objects.get(conversation_id="conv-old")
        self.assertFalse(conv_record.is_active)
        self.assertIsNotNone(conv_record.archived_at)

    def test_new_conversation_does_not_require_dify(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="No dify required",
            question="New session please",
            llm_conversation_id="conv-old",
        )
        MistakeLLMConversation.objects.create(
            mistake=mistake,
            conversation_id="conv-old",
            is_active=True,
        )

        self._login(self.student)

        response = self.client.post(
            reverse("learning:mistake_new_llm_conversation", args=[mistake.pk]),
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        mistake.refresh_from_db()
        self.assertEqual(mistake.llm_conversation_id, "")
        conv_record = MistakeLLMConversation.objects.get(conversation_id="conv-old")
        self.assertFalse(conv_record.is_active)

    def test_student_can_switch_llm_conversation(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="Switch session",
            question="Switch?",
            llm_conversation_id="conv-active",
        )
        MistakeLLMConversation.objects.create(
            mistake=mistake,
            conversation_id="conv-active",
            is_active=True,
            name="Active conversation",
        )
        MistakeLLMConversation.objects.create(
            mistake=mistake,
            conversation_id="conv-archive",
            is_active=False,
            name="Archived conversation",
        )

        self._login(self.student)

        response = self.client.post(
            reverse("learning:mistake_switch_llm_conversation", args=[mistake.pk]),
            data=json.dumps({"conversation_id": "conv-archive", "name": "Reactivated"}),
            content_type="application/json",
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        payload = response.json()
        self.assertEqual(payload["conversation_id"], "conv-archive")
        self.assertEqual(payload["previous_id"], "conv-active")

        mistake.refresh_from_db()
        self.assertEqual(mistake.llm_conversation_id, "conv-archive")

        active_conv = MistakeLLMConversation.objects.get(conversation_id="conv-active")
        new_conv = MistakeLLMConversation.objects.get(conversation_id="conv-archive")

        self.assertFalse(active_conv.is_active)
        self.assertIsNotNone(active_conv.archived_at)
        self.assertTrue(new_conv.is_active)
        self.assertIsNone(new_conv.archived_at)
        self.assertEqual(new_conv.mistake_id, mistake.id)
        self.assertEqual(new_conv.name, "Reactivated")

    def test_assigned_teacher_can_switch_llm_conversation(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="Teacher switch",
            question="Switch view?",
            llm_conversation_id="conv-teacher-active",
        )
        MistakeLLMConversation.objects.create(
            mistake=mistake,
            conversation_id="conv-teacher-active",
            is_active=True,
        )
        MistakeLLMConversation.objects.create(
            mistake=mistake,
            conversation_id="conv-teacher-past",
            is_active=False,
        )

        self._login(self.teacher)

        response = self.client.post(
            reverse("learning:mistake_switch_llm_conversation", args=[mistake.pk]),
            data={"conversation_id": "conv-teacher-past"},
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        mistake.refresh_from_db()
        self.assertEqual(mistake.llm_conversation_id, "conv-teacher-past")

    def test_unassigned_teacher_cannot_switch_llm_conversation(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="Unauthorized switch",
            question="No access",
            llm_conversation_id="conv-protected",
        )
        MistakeLLMConversation.objects.create(
            mistake=mistake,
            conversation_id="conv-protected",
            is_active=True,
        )

        self._login(self.other_teacher)

        response = self.client.post(
            reverse("learning:mistake_switch_llm_conversation", args=[mistake.pk]),
            data={"conversation_id": "conv-protected"},
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 404)

    @mock.patch("learning.views.services.DifyClient.list_conversations")
    def test_conversation_list_join_with_local_records(self, mock_list_conversations):
        created_at_ts = 1_700_000_000
        mistake = Mistake.objects.create(
            student=self.student,
            title="Join check",
            question="test",
            llm_conversation_id="conv-match",
        )
        MistakeLLMConversation.objects.create(
            mistake=mistake,
            conversation_id="conv-match",
            name="Stored conversation",
        )

        mock_list_conversations.return_value = {
            "data": [
                {
                    "id": "conv-match",
                    "name": "",
                    "status": "normal",
                    "created_at": created_at_ts,
                    "updated_at": created_at_ts,
                },
                {
                    "id": "conv-external",
                    "name": "External conv",
                    "status": "normal",
                    "created_at": created_at_ts,
                    "updated_at": created_at_ts,
                },
            ],
            "has_more": False,
            "limit": 20,
        }

        self._login(self.student)
        response = self.client.get(
            reverse("learning:conversation_list"),
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        payload = response.json()
        self.assertIn("conversations", payload)
        match_conv = next(
            (item for item in payload["conversations"] if item["id"] == "conv-match"),
            None,
        )
        self.assertIsNotNone(match_conv)
        self.assertIsNotNone(match_conv["mistake"])
        self.assertEqual(match_conv["mistake"]["id"], mistake.id)
        self.assertEqual(match_conv["mistake"]["title"], mistake.title)
        self.assertTrue(match_conv["is_active_local"])
        self.assertIsNotNone(match_conv["student"])
        self.assertEqual(match_conv["student"]["id"], self.student.id)
        self.assertEqual(match_conv["student"]["username"], self.student.username)

    @mock.patch("learning.views.services.DifyClient.list_conversations")
    def test_teacher_requires_student_param_for_conversation_list(self, mock_list_conversations):
        mock_list_conversations.return_value = {"data": [], "has_more": False, "limit": 20}
        self._login(self.teacher)

        response = self.client.get(
            reverse("learning:conversation_list"),
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 400)
        mock_list_conversations.assert_not_called()

    @mock.patch("learning.views.services.DifyClient.list_conversations")
    def test_assigned_teacher_can_view_student_conversations(self, mock_list_conversations):
        created_at_ts = 1_700_000_000
        mistake = Mistake.objects.create(
            student=self.student,
            title="Teacher view",
            question="test",
            llm_conversation_id="conv-teacher",
        )
        MistakeLLMConversation.objects.create(
            mistake=mistake,
            conversation_id="conv-teacher",
            name="Teacher conversation",
        )

        mock_list_conversations.return_value = {
            "data": [
                {
                    "id": "conv-teacher",
                    "name": "",
                    "status": "normal",
                    "created_at": created_at_ts,
                    "updated_at": created_at_ts,
                }
            ],
            "has_more": False,
            "limit": 20,
        }

        self._login(self.teacher)
        response = self.client.get(
            f"{reverse('learning:conversation_list')}?student={self.student.id}",
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        payload = response.json()
        self.assertEqual(len(payload["conversations"]), 1)
        conv = payload["conversations"][0]
        self.assertEqual(conv["student"]["id"], self.student.id)
        self.assertEqual(conv["student"]["username"], self.student.username)
        self.assertEqual(conv["mistake"]["id"], mistake.id)
        mock_list_conversations.assert_called_once()
        kwargs = mock_list_conversations.call_args.kwargs
        self.assertEqual(kwargs["user_id"], f"user-{self.student.id}")

    @mock.patch("learning.views.services.DifyClient.list_conversations")
    def test_teacher_cannot_view_unassigned_student_conversations(self, mock_list_conversations):
        mock_list_conversations.return_value = {"data": [], "has_more": False, "limit": 20}
        other_student = get_user_model().objects.create_user(
            username="other-student",
            password="pass1234",
            role=get_user_model().Role.STUDENT,
            assigned_teacher=self.other_teacher,
        )

        self._login(self.teacher)
        response = self.client.get(
            f"{reverse('learning:conversation_list')}?student={other_student.id}",
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 404)
        mock_list_conversations.assert_not_called()

    @mock.patch("learning.views.services.DifyClient.get_messages")
    def test_assigned_teacher_can_fetch_llm_messages(self, mock_get_messages):
        mock_get_messages.return_value = {"data": [], "has_more": False, "limit": 20}
        mistake = Mistake.objects.create(
            student=self.student,
            title="Messages",
            question="content",
            llm_conversation_id="conv-msg",
        )

        self._login(self.teacher)
        response = self.client.get(
            reverse("learning:mistake_get_messages", args=[mistake.pk]),
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 200)
        mock_get_messages.assert_called_once()
        kwargs = mock_get_messages.call_args.kwargs
        self.assertEqual(kwargs["user_id"], f"user-{self.student.id}")
        self.assertEqual(kwargs["conversation_id"], "conv-msg")

    @mock.patch("learning.views.services.DifyClient.get_messages")
    def test_teacher_cannot_fetch_other_student_messages(self, mock_get_messages):
        other_student = get_user_model().objects.create_user(
            username="student-two",
            password="pass1234",
            role=get_user_model().Role.STUDENT,
            assigned_teacher=self.other_teacher,
        )
        mistake = Mistake.objects.create(
            student=other_student,
            title="Forbidden",
            question="Why",
            llm_conversation_id="conv-no",
        )

        self._login(self.teacher)
        response = self.client.get(
            reverse("learning:mistake_get_messages", args=[mistake.pk]),
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

        self.assertEqual(response.status_code, 404)
        mock_get_messages.assert_not_called()

    def test_student_mistake_list_page_renders_entries_with_actions(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="List View Entry",
            question="1+1?",
        )
        self._login(self.student)
        UserNotification.objects.create(
            user=self.student,
            title="新公告",
            message="请查看公告",
            link=reverse("accounts:mailbox"),
        )

        response = self.client.get(reverse("learning:mistake_list"))

        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "learning/mistake_list.html")
        self.assertContains(response, "我的错题本")
        self.assertContains(response, "List View Entry")
        self.assertContains(response, reverse("learning:mistake_create"))
        self.assertContains(response, reverse("learning:mistake_detail", args=[mistake.pk]))
        self.assertContains(response, "提交批阅")
        self.assertContains(response, "查看详情")
        self.assertContains(response, 'id="theme-toggle"')
        self.assertContains(response, 'class="stat-card"')
        self.assertContains(response, 'class="mistake-feed"')
        self.assertContains(response, reverse("accounts:profile"))
        self.assertContains(response, reverse("accounts:logout"))
        self.assertContains(response, reverse("accounts:mailbox"))
        self.assertContains(response, "#6366f1")

        if mistake.assigned_teacher:
            self.assertContains(response, mistake.assigned_teacher.username)

    @mock.patch("learning.views.services.DifyClient.get_messages")
    def test_mistake_detail_page_shows_conversation_form(self, mock_get_messages):
        mistake = Mistake.objects.create(
            student=self.student,
            title="Detail Entry",
            question="Explain AM-GM?",
            status=Mistake.Status.SUBMITTED,
            llm_conversation_id="conv-abc",
        )
        student_question = "为什么算术平均数一定大于等于几何平均数？"
        mock_get_messages.return_value = {
            "data": [
                {
                    "query": student_question,
                    "answer": "Because of convexity of log function.",
                    "created_at": 1_700_000_000,
                }
            ],
            "limit": 20,
            "has_more": False,
        }
        self._login(self.student)

        response = self.client.get(reverse("learning:mistake_detail", args=[mistake.pk]))

        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "learning/mistake_detail.html")
        self.assertContains(response, "错题详情")
        self.assertContains(response, "智能助教对话")
        self.assertContains(response, "id=\"llm-form\"")
        self.assertContains(response, student_question)
        self.assertContains(response, "清空对话记录")
        self.assertContains(response, "进度追踪")
        self.assertContains(response, "timeline")
        mock_get_messages.assert_called_once_with(
            user_id=f"user-{mistake.student_id}",
            conversation_id="conv-abc",
        )

    @mock.patch("learning.views.services.DifyClient.delete_conversation")
    def test_student_can_reset_llm_conversation(self, mock_delete):
        mock_delete.return_value = {}
        mistake = Mistake.objects.create(
            student=self.student,
            title="Resettable",
            question="How to integrate?",
            llm_conversation_id="conv-reset",
        )
        mistake.record_llm_message(
            sender=MistakeLLMMessage.Sender.STUDENT,
            content="First attempt",
        )
        mistake.record_llm_message(
            sender=MistakeLLMMessage.Sender.LLM,
            content="Try substitution.",
        )
        self._login(self.student)

        response = self.client.post(reverse("learning:mistake_reset_llm", args=[mistake.pk]))

        self.assertEqual(response.status_code, 302)
        mistake.refresh_from_db()
        self.assertEqual(mistake.llm_conversation_id, "")
        self.assertEqual(mistake.llm_messages.count(), 0)
        mock_delete.assert_called_once_with(
            user_id=f"user-{mistake.student_id}",
            conversation_id="conv-reset",
        )

    def test_mistake_create_page_contains_form_fields(self):
        self._login(self.student)

        response = self.client.get(reverse("learning:mistake_create"))

        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "learning/mistake_form.html")
        self.assertContains(response, "记录新错题")
        for field in ["title", "question", "solution_attempt", "correct_solution", "reflection"]:
            self.assertContains(response, f'name="{field}"')

    def test_student_can_submit_mistake_for_review(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="Need review",
            question="Why derivative zero?",
        )
        self._login(self.student)

        response = self.client.post(reverse("learning:mistake_submit", args=[mistake.pk]))

        self.assertEqual(response.status_code, 302)
        mistake.refresh_from_db()
        self.assertEqual(mistake.status, Mistake.Status.SUBMITTED)
        self.assertIsNotNone(mistake.submitted_at)

    def test_student_can_delete_draft_mistake(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="Removable draft",
            question="Sample question?",
            status=Mistake.Status.DRAFT,
        )
        self._login(self.student)

        response = self.client.post(
            reverse("learning:mistake_delete", args=[mistake.pk]),
            data={"next": reverse("learning:mistake_list")},
        )

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response.url, reverse("learning:mistake_list"))
        self.assertFalse(Mistake.objects.filter(pk=mistake.pk).exists())

    def test_student_cannot_delete_submitted_mistake(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="Submitted entry",
            question="Explain?",
            status=Mistake.Status.SUBMITTED,
        )
        self._login(self.student)

        response = self.client.post(reverse("learning:mistake_delete", args=[mistake.pk]))

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response.url, mistake.get_absolute_url())
        self.assertTrue(Mistake.objects.filter(pk=mistake.pk).exists())

    def test_student_cannot_delete_other_student_mistake(self):
        User = get_user_model()
        other_student = User.objects.create_user(
            username="other-student",
            password="pass1234",
            role=User.Role.STUDENT,
            assigned_teacher=self.other_teacher,
        )
        other_mistake = Mistake.objects.create(
            student=other_student,
            title="Unauthorized delete",
            question="No access?",
            status=Mistake.Status.DRAFT,
        )
        self._login(self.student)

        response = self.client.post(reverse("learning:mistake_delete", args=[other_mistake.pk]))

        self.assertEqual(response.status_code, 404)
        self.assertTrue(Mistake.objects.filter(pk=other_mistake.pk).exists())

    def test_student_detail_page_shows_submit_button_when_draft(self):
        mistake = Mistake.objects.create(
            student=self.student,
            title="Draft entry",
            question="Explain limit?",
            status=Mistake.Status.DRAFT,
        )
        self._login(self.student)

        response = self.client.get(reverse("learning:mistake_detail", args=[mistake.pk]))

        self.assertContains(response, "提交老师批阅")

    def test_teacher_mistake_list_highlights_students(self):
        Mistake.objects.create(
            student=self.student,
            title="Teacher View",
            question="Integration by parts",
            status=Mistake.Status.SUBMITTED,
        )
        self._login(self.teacher)

        response = self.client.get(reverse("learning:mistake_list"))

        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "学生")
        self.assertContains(response, "Teacher View")
        self.assertContains(response, "立即批阅")


class HomePageTests(TestCase):
    def test_homepage_shows_project_intro(self):
        response = self.client.get(reverse("learning:home"))

        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "learning/home.html")
        self.assertContains(response, "高中数学助手")
        self.assertContains(response, "错题管理平台")
        self.assertContains(response, reverse("accounts:login"))
        self.assertContains(response, reverse("accounts:register"))

    def test_nav_shows_login_and_register_for_anonymous(self):
        response = self.client.get(reverse("learning:home"))

        self.assertContains(response, reverse("accounts:login"))
        self.assertContains(response, reverse("accounts:register"))
